481 lines
23 KiB
JavaScript
481 lines
23 KiB
JavaScript
#!/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);
|
||
});
|