318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
|
|
"use strict";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始响应包生成器(initialResponseGenerator)
|
|||
|
|
*
|
|||
|
|
* 三层性能保护(D22):
|
|||
|
|
* ① 去抖:同一条告警 5 分钟内只生成一次
|
|||
|
|
* ② 限流:全局最多 3 个并发 AI 调用,超出的进入 PromiseQueue 排队
|
|||
|
|
* ③ 超时降级:15s 硬超时 + AI 不可用 fallback(不走 AI 也能产出基本响应包)
|
|||
|
|
*
|
|||
|
|
* notification_chain 分级处理(D26):
|
|||
|
|
* - webhook / desktop → 一期实现(标注 sent)
|
|||
|
|
* - dingtalk / wechat / email → pending_external
|
|||
|
|
* - phone → manual_only
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const { INITIAL_RESPONSE_STATUS } = require("./types.cjs");
|
|||
|
|
|
|||
|
|
// ── 三层保护内部状态 ──
|
|||
|
|
const _DEBOUNCE_TTL_MS = 5 * 60 * 1000; // 去抖 5 分钟
|
|||
|
|
const _MAX_CONCURRENT = 3; // 并发上限
|
|||
|
|
const _AI_TIMEOUT_MS = 15000; // AI 超时 15s
|
|||
|
|
|
|||
|
|
const _debounceCache = new Map(); // alert_id → { result, expireAt }
|
|||
|
|
let _activeCount = 0;
|
|||
|
|
const _queue = [];
|
|||
|
|
|
|||
|
|
// ── System Prompt(响应包专用)──────────────────────
|
|||
|
|
|
|||
|
|
const INITIAL_RESPONSE_SYSTEM_PROMPT = `你是 NetMesh 智能运维平台的 AI 初始响应包生成器。
|
|||
|
|
|
|||
|
|
根据传入的告警详情 + 设备画像 + 匹配预案,生成结构化的初始响应包。
|
|||
|
|
严禁输出外网 URL / 厂商文档链接 / 外部工具推荐。
|
|||
|
|
|
|||
|
|
输出严格 JSON:
|
|||
|
|
{
|
|||
|
|
"quick_checklist": [
|
|||
|
|
{ "priority": 1, "action": "先做什么", "purpose": "为什么" }
|
|||
|
|
],
|
|||
|
|
"possible_causes": [
|
|||
|
|
{ "probability": "high|medium|low", "label": "可能原因", "hint": "用什么命令确认" }
|
|||
|
|
],
|
|||
|
|
"warnings": [ "如果有预案缺失/AI 不可用等情况填这里" ]
|
|||
|
|
}
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// ── 主入口 ──────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成初始响应包
|
|||
|
|
* @param {object} alert - alertRecord(含 device_id, metric, level 等)
|
|||
|
|
* @param {object} deviceProfile - 设备画像
|
|||
|
|
* @param {object} matchedSnapshot - 匹配到的 device_plan(双轨)
|
|||
|
|
* @param {object} deps - { readFn, writeFn, aiBridge }
|
|||
|
|
* @returns {Promise<object>} initial_response 结构
|
|||
|
|
*/
|
|||
|
|
async function generate(alert, deviceProfile, matchedSnapshot, deps) {
|
|||
|
|
const debounceKey = `initial_response:${alert.id}`;
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
// ① 去抖检查
|
|||
|
|
const cached = _debounceCache.get(debounceKey);
|
|||
|
|
if (cached && cached.expireAt > now) {
|
|||
|
|
console.info("[initialResponse] skip: 5min 内已生成过");
|
|||
|
|
return cached.result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ② 限流检查
|
|||
|
|
if (_activeCount >= _MAX_CONCURRENT) {
|
|||
|
|
console.info("[initialResponse] queue: 并发已满,等待");
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
_queue.push({ fn: () => generate(alert, deviceProfile, matchedSnapshot, deps), resolve });
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
_activeCount++;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// ③ 超时控制
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const timeoutId = setTimeout(() => controller.abort(), _AI_TIMEOUT_MS);
|
|||
|
|
|
|||
|
|
// 降级检查:AI provider 是否可用
|
|||
|
|
const bridge = deps?.aiBridge || null;
|
|||
|
|
const providerConfigs = bridge?.providerConfigs || null;
|
|||
|
|
const hasProvider = Array.isArray(providerConfigs) && providerConfigs.some((p) => p.enabled !== false);
|
|||
|
|
|
|||
|
|
let responseBody;
|
|||
|
|
if (hasProvider) {
|
|||
|
|
try {
|
|||
|
|
responseBody = await _callAi(bridge, alert, deviceProfile, matchedSnapshot, controller.signal);
|
|||
|
|
clearTimeout(timeoutId);
|
|||
|
|
} catch (err) {
|
|||
|
|
clearTimeout(timeoutId);
|
|||
|
|
console.warn("[initialResponse] AI 调用失败/超时,降级:", err.message);
|
|||
|
|
responseBody = _fallbackGenerate(alert, deviceProfile, matchedSnapshot, { reason: err.message });
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.info("[initialResponse] 降级: AI provider 未配置");
|
|||
|
|
responseBody = _fallbackGenerate(alert, deviceProfile, matchedSnapshot, { reason: "no_provider" });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 追加通知链处理(D26)
|
|||
|
|
const notifications = _resolveNotificationChain(matchedSnapshot?.human_action_guide?.notification_chain);
|
|||
|
|
responseBody.auto_notifications_sent = notifications.auto;
|
|||
|
|
responseBody.pending_notifications = notifications.pending;
|
|||
|
|
|
|||
|
|
// 缓存(带 TTL)
|
|||
|
|
_debounceCache.set(debounceKey, { result: responseBody, expireAt: now + _DEBOUNCE_TTL_MS });
|
|||
|
|
return responseBody;
|
|||
|
|
} finally {
|
|||
|
|
_activeCount--;
|
|||
|
|
// 队列下一个
|
|||
|
|
if (_queue.length > 0) {
|
|||
|
|
const next = _queue.shift();
|
|||
|
|
next.fn().then(next.resolve);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── AI 调用 ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function _callAi(bridge, alert, deviceProfile, matchedSnapshot, signal) {
|
|||
|
|
const userContent = JSON.stringify({
|
|||
|
|
alert: {
|
|||
|
|
alert_type: alert.alert_type,
|
|||
|
|
metric_key: alert.metric,
|
|||
|
|
metric_value: alert.metric_value,
|
|||
|
|
threshold: alert.threshold,
|
|||
|
|
level: alert.level,
|
|||
|
|
triggered_at: alert.triggered_at,
|
|||
|
|
message: alert.message,
|
|||
|
|
},
|
|||
|
|
device: {
|
|||
|
|
host_id: alert.device_id,
|
|||
|
|
label: deviceProfile?.label || alert.device_name,
|
|||
|
|
hostname: deviceProfile?.hostname,
|
|||
|
|
catalog_type: deviceProfile?.catalog_type || alert.alert_catalog,
|
|||
|
|
tier: deviceProfile?.tier,
|
|||
|
|
group: deviceProfile?.group,
|
|||
|
|
location: deviceProfile?.location,
|
|||
|
|
},
|
|||
|
|
matched_plan: {
|
|||
|
|
name: matchedSnapshot?.name || "未知预案",
|
|||
|
|
level: matchedSnapshot?.level,
|
|||
|
|
human_action_guide: matchedSnapshot?.human_action_guide ? {
|
|||
|
|
info_gathering: matchedSnapshot.human_action_guide.info_gathering || [],
|
|||
|
|
} : null,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const provider = (bridge?.providerConfigs || []).find((p) => p.enabled !== false);
|
|||
|
|
if (!provider) throw new Error("no provider");
|
|||
|
|
|
|||
|
|
// 复用 index.cjs 里的 _callAiForRCA 逻辑但简化
|
|||
|
|
const resolved = bridge.resolveProviderApiKey(provider.id);
|
|||
|
|
if (!resolved?.apiKey) throw new Error("api key not available");
|
|||
|
|
|
|||
|
|
const patched = bridge.injectApiKeyIntoRequest(
|
|||
|
|
provider.endpoint || "https://api.minimaxi.com/v1/chat/completions",
|
|||
|
|
{},
|
|||
|
|
provider.id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const http = require("http"), https = require("https");
|
|||
|
|
const u = new URL(patched.url);
|
|||
|
|
const lib = patched.url.startsWith("https") ? https : http;
|
|||
|
|
|
|||
|
|
const bodyStr = JSON.stringify({
|
|||
|
|
model: provider.model || "MiniMax-M1",
|
|||
|
|
messages: [
|
|||
|
|
{ role: "system", content: INITIAL_RESPONSE_SYSTEM_PROMPT },
|
|||
|
|
{ role: "user", content: userContent },
|
|||
|
|
],
|
|||
|
|
temperature: 0.3,
|
|||
|
|
max_tokens: 1500,
|
|||
|
|
stream: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return await new Promise((resolve, reject) => {
|
|||
|
|
const req = lib.request({
|
|||
|
|
hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|||
|
|
path: u.pathname + u.search, method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${resolved.apiKey}` },
|
|||
|
|
timeout: 15000,
|
|||
|
|
}, (res) => {
|
|||
|
|
let chunks = [];
|
|||
|
|
res.on("data", (c) => chunks.push(c));
|
|||
|
|
res.on("end", () => {
|
|||
|
|
try {
|
|||
|
|
const text = Buffer.concat(chunks).toString();
|
|||
|
|
const parsed = JSON.parse(text);
|
|||
|
|
const content = parsed.choices?.[0]?.message?.content
|
|||
|
|
|| parsed.content?.[0]?.text
|
|||
|
|
|| parsed.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|||
|
|
|
|||
|
|
let body;
|
|||
|
|
try {
|
|||
|
|
body = _extractJson(content);
|
|||
|
|
body.status = INITIAL_RESPONSE_STATUS.COMPLETE;
|
|||
|
|
} catch {
|
|||
|
|
body = _fallbackGenerate(null, null, null, { reason: "ai_invalid_json" });
|
|||
|
|
body.status = INITIAL_RESPONSE_STATUS.PARTIAL_AI_INVALID;
|
|||
|
|
body.warnings.push("AI 返回内容无法解析为 JSON");
|
|||
|
|
}
|
|||
|
|
resolve(body);
|
|||
|
|
} catch (e) { reject(e); }
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
req.on("error", reject);
|
|||
|
|
req.on("timeout", () => { req.destroy(); reject(new Error("AI 请求超时")); });
|
|||
|
|
if (signal) signal.addEventListener("abort", () => req.destroy(new Error("aborted")));
|
|||
|
|
req.write(bodyStr);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
} catch (err) {
|
|||
|
|
throw err;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _extractJson(text) {
|
|||
|
|
let cleaned = (text || "")
|
|||
|
|
.replace(/```json\s*/gi, "")
|
|||
|
|
.replace(/```\s*/g, "")
|
|||
|
|
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|||
|
|
.trim();
|
|||
|
|
try { return JSON.parse(cleaned); }
|
|||
|
|
catch {
|
|||
|
|
const m = cleaned.match(/\{[\s\S]*\}/);
|
|||
|
|
if (m) return JSON.parse(m[0]);
|
|||
|
|
throw new Error("no json found");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Fallback 生成(不走 AI)──────────────────────────
|
|||
|
|
|
|||
|
|
function _fallbackGenerate(alert, deviceProfile, matchedSnapshot, opts = {}) {
|
|||
|
|
return {
|
|||
|
|
alert_id: alert?.id || "",
|
|||
|
|
generated_at: Date.now(),
|
|||
|
|
generator: "fallback",
|
|||
|
|
status: opts.reason ? INITIAL_RESPONSE_STATUS.PARTIAL_FALLBACK : INITIAL_RESPONSE_STATUS.COMPLETE_FALLBACK,
|
|||
|
|
device: deviceProfile ? {
|
|||
|
|
host_id: alert?.device_id,
|
|||
|
|
label: deviceProfile.label || alert?.device_name,
|
|||
|
|
hostname: deviceProfile.hostname,
|
|||
|
|
catalog_type: deviceProfile.catalog_type,
|
|||
|
|
tier: deviceProfile.tier,
|
|||
|
|
group: deviceProfile.group,
|
|||
|
|
location: deviceProfile.location,
|
|||
|
|
} : null,
|
|||
|
|
alert: alert ? {
|
|||
|
|
alert_type: alert.alert_type || null,
|
|||
|
|
metric_key: alert.metric,
|
|||
|
|
metric_value: alert.metric_value,
|
|||
|
|
threshold: alert.threshold,
|
|||
|
|
level: alert.level,
|
|||
|
|
triggered_at: alert.triggered_at,
|
|||
|
|
} : null,
|
|||
|
|
quick_checklist: matchedSnapshot?.human_action_guide?.info_gathering || [],
|
|||
|
|
matched_plan: {
|
|||
|
|
name: matchedSnapshot?.name || "未知预案",
|
|||
|
|
level: matchedSnapshot?.level,
|
|||
|
|
},
|
|||
|
|
possible_causes: [], // AI 推断字段 fallback 不填
|
|||
|
|
warnings: opts.reason ? [`AI 不可用,响应包为 fallback 组装:${opts.reason}`] : [],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── notification_chain 分级处理(D26)────────────────
|
|||
|
|
|
|||
|
|
function _resolveNotificationChain(chain) {
|
|||
|
|
const auto = [];
|
|||
|
|
const pending = [];
|
|||
|
|
if (!Array.isArray(chain)) return { auto, pending };
|
|||
|
|
|
|||
|
|
for (const item of chain) {
|
|||
|
|
if (!item.auto) continue;
|
|||
|
|
switch (item.channel) {
|
|||
|
|
case "webhook":
|
|||
|
|
auto.push({ role: item.role, channel: "webhook", status: "sent" });
|
|||
|
|
break;
|
|||
|
|
case "desktop":
|
|||
|
|
auto.push({ role: item.role, channel: "desktop", status: "sent" });
|
|||
|
|
break;
|
|||
|
|
case "dingtalk":
|
|||
|
|
case "wechat":
|
|||
|
|
case "email":
|
|||
|
|
auto.push({ role: item.role, channel: item.channel, status: "pending_external", note: "需外置通知网关(P5 实现)" });
|
|||
|
|
pending.push({ ...item, status: "pending_external" });
|
|||
|
|
break;
|
|||
|
|
case "phone":
|
|||
|
|
pending.push({ ...item, status: "manual_only" });
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { auto, pending };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 清理(测试用)────────────────────────────────────
|
|||
|
|
|
|||
|
|
function _resetForTest() {
|
|||
|
|
_debounceCache.clear();
|
|||
|
|
_activeCount = 0;
|
|||
|
|
_queue.length = 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
generate,
|
|||
|
|
_resetForTest,
|
|||
|
|
// 常量
|
|||
|
|
_DEBOUNCE_TTL_MS,
|
|||
|
|
_MAX_CONCURRENT,
|
|||
|
|
_AI_TIMEOUT_MS,
|
|||
|
|
};
|