Files

481 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env node
/**
* 行业情报日更引擎 (industry-daily)
* ==================================
* 社区新形态:每个行业 = 一个「XX情报官」机器人,每天一篇《每日情报》
* - 内容 = 该行业的新模式 / 新打法 / 新策略(可操作、观点锐利、来源可追溯、结构固定、逻辑自洽)
* - 价值度闸门:素材不足 / 新闻单一 / 价值分低 → 当日不发(宁缺毋滥)
* - 问答:只回自己帖子下的真人提问;不相关的问题回复并标注边界
*
* 用法:
* node scripts/industry-daily.mjs daily --all
* node scripts/industry-daily.mjs daily --industry=cb-ecommerce
* node scripts/industry-daily.mjs respond --all
*/
import "dotenv/config";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import OpenAI from "openai";
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NEWS_DIR = path.join(__dirname, "..", "data", "industry-news");
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(`${base}${sep}connection_limit=5&pool_timeout=30`) });
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 ts = () => new Date().toISOString();
// ============================ 价值度闸门阈值 ============================
const GATE = {
FRESH_DAYS: 5, // 素材新鲜窗口(天);用足 10 天素材库,质量仍由 AI 价值分把关
MIN_FRESH: 6, // 窗口内最少素材条数
MIN_SOURCES: 2, // 最少来源数
MIN_THEMES: 3, // 最少主题簇数
MAX_THEME_SHARE: 0.5, // 单一主题最大占比
MIN_SCORE: 65, // AI 价值分下限
};
const SELF_CHECK = { EACH_MIN: 6, AVG_MIN: 7.0 };
// ============================ 行业情报官配置 ============================
// key 对应素材库文件名与社区板块 slug;bot 为单机器人情报官
const INDUSTRIES = [
{
key: "cb-ecommerce", title: "跨境电商", short: "出海", board: "cb-ecommerce",
bot: { key: "cb_intel", name: "跨境情报官·阿海", email: "bot_cb_intel@zhuiguang.ai" },
lens: "平台规则、流量结构、物流与合规的变化,以及卖家可立即采用的动作",
},
{
key: "fi-insurance", title: "保险理财", short: "保险", board: "fi-insurance",
bot: { key: "fi_ins_intel", name: "保险情报官·安叔", email: "bot_fi_ins_intel@zhuiguang.ai" },
lens: "监管与产品结构变化、银行与保险资金动向,以及家庭资产配置的应对",
},
{
key: "fi-stock", title: "股票基金", short: "市场", board: "fi-stock",
bot: { key: "fi_stock_intel", name: "市场情报官·K线", email: "bot_fi_stock_intel@zhuiguang.ai" },
lens: "资金与估值信号、板块轮动、公募私募动向,以及仓位与风险的应对",
},
{
key: "cb-brand", title: "品牌出海", short: "品牌", board: "cb-brand",
bot: { key: "brand_intel", name: "品牌情报官·阿牌", email: "bot_brand_intel@zhuiguang.ai" },
lens: "渠道与内容分发变化、品牌增长与本地化打法,以及可复制的增长动作",
},
{
key: "ai-tools-app", title: "AI应用", short: "AI", board: "ai-tools-app",
bot: { key: "ai_intel", name: "AI情报官·普罗", email: "bot_ai_intel@zhuiguang.ai" },
lens: "模型能力与定价变化、AI 产品商业化与交付边界,以及可落地的应用机会",
},
{
key: "ct-short-video", title: "内容创作", short: "内容", board: "ct-short-video",
bot: { key: "ct_intel", name: "内容情报官·爆点", email: "bot_ct_intel@zhuiguang.ai" },
lens: "平台算法与流量分配、内容形态与变现方式的变化,以及创作者可执行的调整",
},
];
// 情报官人设:优先读角色库(data/bot-characters.json),读不到则退回内置描述
let CHAR_MAP = null;
function loadChars() {
if (CHAR_MAP) return CHAR_MAP;
CHAR_MAP = {};
try {
const raw = fs.readFileSync(path.join(__dirname, "..", "data", "bot-characters.json"), "utf8");
for (const c of JSON.parse(raw).characters || []) CHAR_MAP[c.key] = c;
} catch (e) {
console.warn(`${ts()} ⚠️ 角色库读取失败,使用内置人设: ${e.message}`);
}
return CHAR_MAP;
}
function PERSONA(def) {
const c = loadChars()[def.bot.key];
const p = c?.personality;
if (!p) {
return [
`你是「${def.bot.name}」,${def.title}行业的情报官。`,
`你的观察视角:${def.lens}。`,
"风格:冷静、克制、不寒暄、不营销;不编造任何数字与新闻。",
].join("\n");
}
return [
`你是「${c.displayName}」,${p.identity}。`,
`观察视角:${def.lens}。`,
`立场:${p.stance}`,
`表达风格:${p.speakingStyle}`,
`口头禅(可自然使用):${p.catchphrase}`,
`禁止出现的表达:${(p.forbiddenPatterns || []).join("、")}`,
].join("\n");
}
// ============================ 基础工具 ============================
function readStore(key) {
const file = path.join(NEWS_DIR, `${key}.json`);
if (!fs.existsSync(file)) return [];
try {
const arr = JSON.parse(fs.readFileSync(file, "utf8"));
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
async function callAI(system, user, maxTokens = 16384) {
// DeepSeek V4 reasoning token 计入 max_tokens,预算不足会返回空内容
const r = await openai.chat.completions.create({
model: MODEL,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
temperature: 0.7,
max_tokens: maxTokens,
});
return r.choices[0]?.message?.content || "";
}
async function callAIJson(system, user, maxTokens = 8192, tries = 3) {
for (let i = 0; i < tries; i++) {
const text = await callAI(system, user, maxTokens);
const m = text.match(/\{[\s\S]*\}/);
if (!m) { console.warn(`${ts()} ⚠️ JSON 缺失,重试 ${i + 1}/${tries}`); continue; }
try {
return JSON.parse(m[0]);
} catch {
try { return JSON.parse(m[0].replace(/,\s*}/g, "}").replace(/,\s*]/g, "]")); } catch { console.warn(`${ts()} ⚠️ JSON 解析失败,重试`); }
}
}
throw new Error("AI 结构化输出连续失败");
}
async function ensureBot(def) {
const b = def.bot;
let u = await prisma.user.findUnique({ where: { email: b.email } });
if (!u) {
u = await prisma.user.create({
data: { email: b.email, name: b.name, oauthProvider: "bot", oauthId: `bot_${b.key}`, role: "user", isBot: true },
});
console.log(`${ts()} + 创建情报官账号: ${b.name}`);
}
try {
const exists = await prisma.botConfig.findFirst({ where: { userId: u.id } });
if (!exists) {
await prisma.botConfig.create({
data: { userId: u.id, displayName: b.name, personality: { identity: `${def.title}行业情报官`, expertise: [def.title, "行业情报", "商业分析"], stance: "只讲事实与判断", speakingStyle: "冷静克制" }, primaryForums: [def.board], activeHours: [] },
});
}
} catch (e) { console.warn(`${ts()} ⚠️ BotConfig: ${e.message}`); }
return u;
}
// ============================ 价值度闸门 ============================
function pickFresh(def) {
const cutoff = new Date(Date.now() - GATE.FRESH_DAYS * 86400000).toISOString().slice(0, 10);
const all = readStore(def.key);
const fresh = all.filter((n) => n.date >= cutoff && n.title);
const sources = [...new Set(fresh.map((n) => n.source).filter(Boolean))];
return { fresh, sources };
}
async function evaluateValue(def, fresh, sources) {
if (fresh.length < GATE.MIN_FRESH) return { pass: false, reason: `素材不足(近${GATE.FRESH_DAYS}天 ${fresh.length} 条 < ${GATE.MIN_FRESH})` };
if (sources.length < GATE.MIN_SOURCES) return { pass: false, reason: `来源单一(仅 ${sources.length} 个来源 < ${GATE.MIN_SOURCES})` };
const list = fresh.slice(0, 24).map((n, i) => `[${i + 1}] ${n.title}(${n.source || "未知来源"})`).join("\n");
const judge = await callAIJson(
`你是内容价值评审。判断这批行业素材是否足以支撑一篇"有深度、有逻辑、可操作"的行业情报。只输出 JSON:{"score":0-100,"worth":true/false,"themes":[{"name":"主题名","count":条目数}],"reasons":"一句话理由"}。评分标准:主题是否多元、是否含可提炼的新模式/新打法信号、是否只是同质转载。`,
`行业:${def.title}\n素材(近${GATE.FRESH_DAYS}天):\n${list}`,
16384
);
const themes = Array.isArray(judge?.themes) ? judge.themes.filter((t) => t && t.name) : [];
const total = themes.reduce((s, t) => s + (Number(t.count) || 0), 0) || 1;
const maxShare = themes.length ? Math.max(...themes.map((t) => (Number(t.count) || 0) / total)) : 1;
const score = Number(judge?.score) || 0;
if (!judge?.worth) return { pass: false, reason: `AI 判定价值不足(score ${score}): ${judge?.reasons || ""}` };
if (themes.length < GATE.MIN_THEMES) return { pass: false, reason: `新闻单一(主题簇 ${themes.length} < ${GATE.MIN_THEMES}): ${themes.map((t) => t.name).join("/")}` };
if (maxShare > GATE.MAX_THEME_SHARE) return { pass: false, reason: `单主题占比 ${(maxShare * 100).toFixed(0)}% > ${GATE.MAX_THEME_SHARE * 100}%` };
if (score < GATE.MIN_SCORE) return { pass: false, reason: `价值分 ${score} < ${GATE.MIN_SCORE}` };
return { pass: true, score, themes, reasons: judge?.reasons || "" };
}
// ============================ 内容生成与自检 ============================
function materialBlock(fresh) {
return fresh.slice(0, 20).map((n, i) => `[${i + 1}] ${n.title}${n.url ? `\n 链接:${n.url}` : ""}(来源:${n.source || "未知"})`).join("\n");
}
const STYLE_RULES = `写作硬性要求(缺一不可):
1) 可操作解读:每条都要写清"对我们意味着什么 + 具体可以做什么",禁止只复述新闻;
2) 观点锐利:敢下判断(如"这个模式在国内跑不通,因为…"),不骑墙、不含糊;
3) 来源可追溯:所有数字、案例必须来自给定素材,宁缺不编;引用素材时标注其标题关键词;
4) 结构固定:严格按模板三段式输出;
5) 逻辑自洽:每条走完"现象 → 机制(为什么成立)→ 推论 → 动作"的因果链,禁止跳跃堆砌。`;
const TEMPLATE = `【{industry}·每日情报】{date}
🆕 新模式
1. (现象)…(机制)…(对从业者的含义)…
2. …
⚔️ 新打法
1. (谁在这么做)…(具体动作,可直接抄/试)…
2. …
🎯 新策略
1. (判断)…(观察指标,后续怎么验证)…
2. …
🔗 来源
- 素材标题关键词(来源媒体)`;
async function generateIntel(def, fresh, feedback) {
const sys = [PERSONA(def), STYLE_RULES, "只输出正文(从 🆕 开始,到来源结束),不要额外解释。"].join("\n");
const user = [
`请基于以下真实素材,产出今日《${def.title}·每日情报》。`,
`今天日期:${new Date().toISOString().slice(0, 10)}`,
`素材:\n${materialBlock(fresh)}`,
`模板(严格遵循):\n${TEMPLATE.replace("{industry}", def.title).replace("{date}", new Date().toISOString().slice(0, 10))}`,
"若某类目素材只能支撑 1 条,就只写 1 条;不要凑数。",
feedback ? `\n【上一版质检问题,请针对性重写并全部修复】:\n- ${feedback}` : "",
].join("\n\n");
return (await callAI(sys, user, 16384)).trim();
}
async function selfCheck(def, body, fresh) {
const judge = await callAIJson(
`你是严格的内容质检员。对这篇行业情报按五维打分(0-10)并给问题清单。只输出 JSON:{"scores":{"actionable":0-10,"sharp":0-10,"sourced":0-10,"structure":0-10,"logic":0-10},"issues":["具体问题"]}。标准:actionable=是否给出可直接执行的动作;sharp=是否有明确判断;sourced=是否基于给定素材且未编造;structure=是否遵循固定三段式;logic=因果链是否完整。`,
`行业:${def.title}\n文章:\n${body}\n\n可对照的素材:\n${materialBlock(fresh).slice(0, 1500)}`,
16384
);
const s = judge?.scores || {};
const vals = ["actionable", "sharp", "sourced", "structure", "logic"].map((k) => Number(s[k]) || 0);
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
const pass = vals.every((v) => v >= SELF_CHECK.EACH_MIN) && avg >= SELF_CHECK.AVG_MIN;
return { pass, avg, scores: s, issues: Array.isArray(judge?.issues) ? judge.issues : [] };
}
// ============================ 情报索引(每板块置顶导航帖) ============================
function weekStartLabel(d) {
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const shift = (x.getUTCDay() + 6) % 7; // 周一为一周起点
x.setUTCDate(x.getUTCDate() - shift);
return `${x.getUTCFullYear()}/${String(x.getUTCMonth() + 1).padStart(2, "0")}/${String(x.getUTCDate()).padStart(2, "0")} 当周`;
}
async function updateIndex(def, bot, board) {
const slug = `intel-index-${def.key}`;
const topics = await prisma.forumTopic.findMany({
where: { categoryId: board.id, userId: bot.id, slug: { startsWith: `intel-${def.key}-` } },
orderBy: { createdAt: "desc" },
take: 40,
select: { id: true, title: true, createdAt: true, thinktank: true },
});
const lines = [];
let curWeek = "";
for (const t of topics) {
const d = new Date(t.createdAt);
const wk = weekStartLabel(d);
if (wk !== curWeek) { lines.push(`\n**${wk}**`); curWeek = wk; }
const day = `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}`;
const score = t.thinktank?.valueScore ? ` \`价值分 ${t.thinktank.valueScore}\`` : "";
lines.push(`- **${day}** [${t.title}](/community/topic/${t.id})${score}`);
}
const content = [
`# 「${board.name}」情报索引`,
"",
"本页由情报官自动维护,收录近期每日情报(最新在上),有新情报发布时自动更新。",
"",
`共 ${topics.length} 篇 · 每篇均基于真实素材整理、标注来源,不编造数据与案例。`,
...(lines.length ? lines : ["", "(暂无情报)"]),
"",
"---",
`*想看某天的完整情报,直接点标题;有具体场景想问,回帖即可,情报官会答。*`,
].join("\n");
const exist = await prisma.forumTopic.findUnique({ where: { slug }, select: { id: true } });
if (exist) {
await prisma.forumTopic.update({ where: { id: exist.id }, data: { title: `「${board.name}」情报索引`, content, isPinned: true } });
console.log(`${ts()} 📑 [${def.key}] 情报索引已更新(收录 ${topics.length} 篇)`);
return exist.id;
}
const t = await prisma.forumTopic.create({
data: { categoryId: board.id, userId: bot.id, title: `「${board.name}」情报索引`, slug, content, isPinned: true, thinktank: { kind: "intel-index", industry: def.key } },
});
await prisma.forumCategory.update({ where: { id: board.id }, data: { topicCount: { increment: 1 } } });
console.log(`${ts()} 📑 [${def.key}] 情报索引已创建 topic#${t.id}`);
return t.id;
}
// ============================ 发布 ============================
async function publish(def, body, valueInfo) {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) throw new Error(`板块 ${def.board} 不存在`);
const bot = await ensureBot(def);
const date = new Date().toISOString().slice(0, 10);
const slug = `intel-${def.key}-${date.replace(/-/g, "")}`;
const existing = await prisma.forumTopic.findUnique({ where: { slug } });
if (existing) { console.log(`${ts()} ⏭ [${def.key}] 今日已发(topic#${existing.id})`); return existing; }
const content = `${body}\n\n---\n*由「${def.bot.name}」基于当日真实素材自动整理,供学习参考,不构成投资/经营建议。价值分 ${valueInfo.score}。*`;
const topic = await prisma.forumTopic.create({
data: {
categoryId: board.id,
userId: bot.id,
title: `【${def.short}·每日情报】${date}`,
slug,
content,
isPinned: false,
thinktank: { kind: "daily-intel", industry: def.key, valueScore: valueInfo.score, themes: valueInfo.themes?.map((t) => t.name) || [], lastQuestionAt: null },
},
});
await prisma.forumCategory.update({ where: { id: board.id }, data: { topicCount: { increment: 1 } } });
console.log(`${ts()} ✅ [${def.key}] 已发布每日情报 topic#${topic.id}(价值分 ${valueInfo.score})`);
await updateIndex(def, bot, board);
return topic;
}
async function runDaily(def) {
const { fresh, sources } = pickFresh(def);
console.log(`${ts()} 🔎 [${def.key}] 近${GATE.FRESH_DAYS}天素材 ${fresh.length} 条 / ${sources.length} 来源`);
const value = await evaluateValue(def, fresh, sources);
if (!value.pass) {
console.log(`${ts()} ⏭ [${def.key}] 跳过发布:${value.reason}`);
return { key: def.key, status: "skipped", reason: value.reason };
}
console.log(`${ts()} ✅ [${def.key}] 通过价值闸门(score ${value.score},主题:${value.themes.map((t) => t.name).join("/")})`);
let body = await generateIntel(def, fresh);
let check = await selfCheck(def, body, fresh);
if (!check.pass) {
console.warn(`${ts()} ⚠️ [${def.key}] 自检未过(均分 ${check.avg.toFixed(1)}),重写一次。问题:${check.issues.join(";").slice(0, 200)}`);
body = await generateIntel(def, fresh, check.issues.join("\n- "));
check = await selfCheck(def, body, fresh);
}
if (!check.pass) {
const reason = `自检二次未过(均分 ${check.avg.toFixed(1)}):${check.issues.join(";").slice(0, 200)}`;
console.log(`${ts()} ⏭ [${def.key}] 跳过发布:${reason}`);
return { key: def.key, status: "skipped", reason };
}
if (body.length < 200) return { key: def.key, status: "skipped", reason: "生成长度过短" };
const topic = await publish(def, body, value);
return { key: def.key, status: "published", topicId: topic.id, score: value.score, selfAvg: Number(check.avg.toFixed(1)) };
}
// ============================ 真人问答(单机器人 + 边界标注) ============================
async function respond(def) {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) return { key: def.key, status: "error", reason: `板块 ${def.board} 不存在` };
const bot = await ensureBot(def);
const topics = await prisma.forumTopic.findMany({ where: { categoryId: board.id, userId: bot.id }, orderBy: { createdAt: "desc" }, take: 10 });
let replied = 0;
for (const topic of topics) {
const meta = topic.thinktank || {};
if (meta.kind !== "daily-intel") continue;
const since = meta.lastQuestionAt ? new Date(meta.lastQuestionAt) : new Date(topic.createdAt);
const questions = await prisma.forumPost.findMany({
where: { topicId: topic.id, user: { isBot: false }, createdAt: { gt: since } },
orderBy: { createdAt: "asc" },
include: { user: { select: { name: true } } },
});
for (const q of questions) {
try {
const judge = await callAIJson(
`判断提问是否与该帖主题/该行业相关。只输出 JSON:{"related":true/false,"topic":"提问的核心话题","hint":"若是行业边缘问题,指明可回答的边界"}。`,
`帖子标题:${topic.title}\n帖子正文节选:${(topic.content || "").slice(0, 800)}\n\n提问:${q.content.slice(0, 400)}`,
16384
);
const related = judge?.related === true;
const sys = [PERSONA(def), related ? "请直接、专业地回答,给出可操作判断,200-400字。" : `该提问超出本行业情报范围(${judge?.hint || "与本帖主题无关"})。请简短回复:说明边界,只就该行业中与之相关的部分给出看法(80-150字),并建议合适的提问方向。`].join("\n");
const reply = await callAI(sys, `提问(来自 ${q.user?.name || "用户"}):${q.content.slice(0, 500)}`, 8192);
await prisma.forumPost.create({
data: { topicId: topic.id, userId: bot.id, content: reply.trim() },
});
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
replied++;
console.log(`${ts()} ↩️ [${def.key}] 已回复 post#${q.id}(相关=${related})`);
} catch (e) {
console.error(`${ts()} 回复失败 post#${q.id}: ${e.message}`);
}
}
await prisma.forumTopic.update({ where: { id: topic.id }, data: { thinktank: { ...meta, lastQuestionAt: new Date().toISOString() } } });
}
return { key: def.key, status: "ok", replied };
}
// ============================ CLI ============================
function parseArgs() {
const args = {};
for (const a of process.argv.slice(3)) {
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
args[k] = v || true;
}
}
return args;
}
async function logTask(action, results, startedAt, error) {
const published = results.filter((r) => r.status === "published").map((r) => ({ key: r.key, score: r.score, selfAvg: r.selfAvg }));
const skipped = results.filter((r) => r.status === "skipped").map((r) => ({ key: r.key, reason: r.reason }));
try {
await prisma.taskLog.create({
data: {
taskKey: "industry-daily",
taskName: "行业情报日更引擎",
status: error || results.some((r) => r.status === "error") ? "failed" : "completed",
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "cron",
result: { action, published, skipped },
error: error ? String(error.message || error).slice(0, 1000) : null,
},
});
} catch (e) { console.error(`${ts()} TaskLog error:`, e.message); }
}
async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少 DEEPSEEK_API_KEY");
const startedAt = new Date();
const cmd = process.argv[2];
const args = parseArgs();
const targets = args.all ? INDUSTRIES : INDUSTRIES.filter((d) => d.key === args.industry);
if (!targets.length) throw new Error(`未匹配行业: ${args.industry}`);
if (cmd !== "daily" && cmd !== "respond" && cmd !== "index") {
console.error("usage: daily|respond|index [--industry=slug] [--all]");
process.exit(1);
}
const results = [];
for (const def of targets) {
try {
if (cmd === "index") {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) throw new Error(`板块 ${def.board} 不存在`);
const bot = await ensureBot(def);
const id = await updateIndex(def, bot, board);
results.push({ key: def.key, status: "published", topicId: id });
} else {
results.push(cmd === "daily" ? await runDaily(def) : await respond(def));
}
} catch (e) {
console.error(`${ts()} ❌ [${def.key}] 失败:`, e.message);
results.push({ key: def.key, status: "error", reason: e.message });
}
}
await logTask(cmd, results, startedAt);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
try { await prisma.$disconnect(); } catch {}
process.exit(1);
});