284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
/* iAOP 对话助手增强版(issue #133 / PRD 5.4「④ LLM 网关 + RAG」)。
|
||||
|
|
*
|
|||
|
|
* 在 chat_widget.html(#77 雏形)之上完善:
|
|||
|
|
* - 消息流 + 每消息元信息(路由分级 / 模型 / 耗时 / 高利害确认提示)
|
|||
|
|
* - RAG 引用溯源:citations.json(模板知识库语料)关键词检索,回答附引用列表
|
|||
|
|
* - 路由分级提示:镜像 core/llm-gateway/router.py 语义(local/cloud/block)
|
|||
|
|
* - DLP 拦截反馈:镜像 core/llm-gateway/dlp.py 的 DLP_DEFAULT_RULES 内置保底规则,
|
|||
|
|
* 命中即阻止外发并给出可感知的拦截条(可转本地处理)
|
|||
|
|
* - 三场景:NL 查询 / 报警解释(severity+点位 → 原因+处置建议,P0 提示人工确认)/
|
|||
|
|
* 交接班报告(按 handover_brief.ti.yaml 章节模板生成,计时展示 ≤2min 目标)
|
|||
|
|
*
|
|||
|
|
* 直连已部署推理服务 http://39.101.182.167:30800(OpenAI 兼容 /v1/chat/completions)。
|
|||
|
|
*/
|
|||
|
|
"use strict";
|
|||
|
|
|
|||
|
|
var INFER_BASE = "http://39.101.182.167:30800";
|
|||
|
|
var state = {
|
|||
|
|
model: null,
|
|||
|
|
citations: null, // citations.json 语料
|
|||
|
|
pending: null // 被 DLP 拦截待处置的消息
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ---------------- DLP 内置保底规则(镜像 dlp.py DLP_DEFAULT_RULES) ---------------- */
|
|||
|
|
var DLP_RULES = [
|
|||
|
|
{ name: "pii_id_card", category: "pii", kind: "regex",
|
|||
|
|
pattern: /\b\d{17}[\dXx]\b/, description: "身份证号(18 位)" },
|
|||
|
|
{ name: "pii_mobile", category: "pii", kind: "regex",
|
|||
|
|
pattern: /\b1[3-9]\d{9}\b/, description: "中国大陆手机号" },
|
|||
|
|
{ name: "pii_email", category: "pii", kind: "regex",
|
|||
|
|
pattern: /[\w.+-]+@[\w-]+\.[\w.-]+/, description: "邮箱地址" },
|
|||
|
|
{ name: "pii_ipv4", category: "pii", kind: "regex",
|
|||
|
|
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/, description: "IPv4 地址" },
|
|||
|
|
{ name: "credential_ak", category: "credential", kind: "regex",
|
|||
|
|
pattern: /\b(?:AKIA|LTAI)[0-9A-Z]{16,}\b/, description: "云访问密钥 ID" },
|
|||
|
|
{ name: "credential_kv", category: "credential", kind: "regex",
|
|||
|
|
pattern: /(?:access[_-]?key|secret|password|token)\s*[:=]\s*\S+/i,
|
|||
|
|
description: "密钥/口令键值对" },
|
|||
|
|
{ name: "process_formula", category: "process-keyword", kind: "keyword",
|
|||
|
|
pattern: "配方", description: "配方类敏感词" }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function dlpCheck(text) {
|
|||
|
|
var hits = [];
|
|||
|
|
DLP_RULES.forEach(function (r) {
|
|||
|
|
var m = text.match(r.pattern);
|
|||
|
|
if (m) hits.push({ rule: r, matched: m[0] });
|
|||
|
|
});
|
|||
|
|
return hits;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 路由分级(镜像 router.py:block > local > cloud > 默认 local) ---------------- */
|
|||
|
|
// 通用/脱敏问法(可云端):不含点位/工艺敏感词的常识类问题
|
|||
|
|
var GENERIC_RE = /^(什么是|请解释|介绍一下|怎么理解|如何理解)/;
|
|||
|
|
// 本地(敏感/核心)线索:点位号、生产/工艺词
|
|||
|
|
var LOCAL_HINT_RE = /([A-Z]{2,}-\d+\.|[点位炉班批纯杂温度压力流量生产工艺工况])/;
|
|||
|
|
|
|||
|
|
function routeDecision(text, dlpHits) {
|
|||
|
|
if (dlpHits.length) {
|
|||
|
|
return { target: "block", reason: "DLP 出站检查命中,拦截外发(fail-closed)" };
|
|||
|
|
}
|
|||
|
|
if (GENERIC_RE.test(text) && !LOCAL_HINT_RE.test(text)) {
|
|||
|
|
return { target: "cloud", reason: "通用/脱敏问题,DLP 放行,可路由云端" };
|
|||
|
|
}
|
|||
|
|
return { target: "local", reason: "敏感/核心或未知(默认保守),本地处理(数据不出厂)" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- RAG 引用检索(citations.json 关键词重叠,Demo 级) ---------------- */
|
|||
|
|
function retrieveCitations(query, topK) {
|
|||
|
|
if (!state.citations) return [];
|
|||
|
|
var terms = query.replace(/[^\w一-鿿]+/g, " ").split(" ")
|
|||
|
|
.filter(function (t) { return t.length >= 2; });
|
|||
|
|
var scored = [];
|
|||
|
|
state.citations.documents.forEach(function (doc) {
|
|||
|
|
doc.sections.forEach(function (sec) {
|
|||
|
|
var hay = doc.title + " " + sec.heading + " " + sec.snippet;
|
|||
|
|
var score = 0;
|
|||
|
|
terms.forEach(function (t) { if (hay.indexOf(t) >= 0) score += t.length; });
|
|||
|
|
if (score > 0) {
|
|||
|
|
scored.push({ title: doc.title, source: doc.source,
|
|||
|
|
heading: sec.heading, snippet: sec.snippet, score: score });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
scored.sort(function (a, b) { return b.score - a.score; });
|
|||
|
|
return scored.slice(0, topK || 3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 推理服务 ---------------- */
|
|||
|
|
function checkHealth() {
|
|||
|
|
fetch(INFER_BASE + "/health").then(function (r) {
|
|||
|
|
if (!r.ok) throw new Error(r.status);
|
|||
|
|
document.getElementById("infer-dot").className = "dot ok";
|
|||
|
|
document.getElementById("infer-text").textContent = "推理服务在线";
|
|||
|
|
}).catch(function () {
|
|||
|
|
document.getElementById("infer-dot").className = "dot down";
|
|||
|
|
document.getElementById("infer-text").textContent = "推理服务不可达";
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function loadModel() {
|
|||
|
|
return fetch(INFER_BASE + "/v1/models").then(function (r) { return r.json(); })
|
|||
|
|
.then(function (d) {
|
|||
|
|
// 兼容 {"models":[...]} 与 OpenAI {"data":[{"id":...}]} 两种形态
|
|||
|
|
if (d && d.models && d.models.length) state.model = d.models[0];
|
|||
|
|
else if (d && d.data && d.data.length) state.model = d.data[0].id;
|
|||
|
|
}).catch(function () {});
|
|||
|
|
}
|
|||
|
|
function chatCompletions(messages) {
|
|||
|
|
return fetch(INFER_BASE + "/v1/chat/completions", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ model: state.model || "iaop-ti-cl4-v1",
|
|||
|
|
messages: messages, max_tokens: 1024 })
|
|||
|
|
}).then(function (r) {
|
|||
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|||
|
|
return r.json();
|
|||
|
|
}).then(function (d) {
|
|||
|
|
var c = d && d.choices && d.choices[0];
|
|||
|
|
return (c && c.message && c.message.content) || "(空响应)";
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 消息渲染 ---------------- */
|
|||
|
|
function el(tag, cls, text) {
|
|||
|
|
var n = document.createElement(tag);
|
|||
|
|
if (cls) n.className = cls;
|
|||
|
|
if (text !== undefined) n.textContent = text;
|
|||
|
|
return n;
|
|||
|
|
}
|
|||
|
|
function addMsg(who, text, meta) {
|
|||
|
|
var box = document.getElementById("messages");
|
|||
|
|
var div = el("div", "msg " + who);
|
|||
|
|
div.appendChild(el("div", "who", who === "user" ? "我" : "iAOP"));
|
|||
|
|
var t = el("div", "text");
|
|||
|
|
t.textContent = text;
|
|||
|
|
div.appendChild(t);
|
|||
|
|
if (meta) {
|
|||
|
|
var m = el("div", "meta");
|
|||
|
|
meta.forEach(function (node) { m.appendChild(node); });
|
|||
|
|
div.appendChild(m);
|
|||
|
|
}
|
|||
|
|
box.appendChild(div);
|
|||
|
|
box.scrollTop = box.scrollHeight;
|
|||
|
|
return div;
|
|||
|
|
}
|
|||
|
|
function routeTag(route) {
|
|||
|
|
var map = { local: ["route-local", "路由:本地(数据不出厂)"],
|
|||
|
|
cloud: ["route-cloud", "路由:云端(脱敏放行)"],
|
|||
|
|
block: ["route-block", "路由:拦截"] };
|
|||
|
|
var v = map[route] || map.local;
|
|||
|
|
return el("span", "route-tag " + v[0], v[1]);
|
|||
|
|
}
|
|||
|
|
function appendCitations(msgDiv, citations) {
|
|||
|
|
if (!citations.length) {
|
|||
|
|
msgDiv.appendChild(el("div", "no-citation", "未命中知识库,以上为模型直接回答(无引用)"));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
var box = el("div", "citations");
|
|||
|
|
box.appendChild(el("div", "cite-title", "引用溯源(RAG,共 " + citations.length + " 条)"));
|
|||
|
|
citations.forEach(function (c, i) {
|
|||
|
|
box.appendChild(el("div", "cite-item",
|
|||
|
|
"[" + (i + 1) + "] 《" + c.title + "》 " + c.heading + " — " + c.snippet));
|
|||
|
|
});
|
|||
|
|
msgDiv.appendChild(box);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 场景 Prompt 构建 ---------------- */
|
|||
|
|
function buildMessages(scenario, question, citations) {
|
|||
|
|
if (scenario === "alarm_explain") {
|
|||
|
|
var sev = document.getElementById("alarm-severity").value;
|
|||
|
|
var point = document.getElementById("alarm-point").value.trim() || "(未填点位)";
|
|||
|
|
return [
|
|||
|
|
{ role: "system", content: "你是 iAOP 工业报警解释助手。针对告警给出结构化回答:"
|
|||
|
|
+ "【可能原因】分条列出;【处置建议】分条列出可执行步骤;【是否需要人工确认】。"
|
|||
|
|
+ "用简体中文,简洁专业。" },
|
|||
|
|
{ role: "user", content: "告警级别 " + sev + ",点位 " + point +
|
|||
|
|
",告警描述:" + question }
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
if (scenario === "shift_handover") {
|
|||
|
|
var shift = document.getElementById("handover-shift").value.trim() || "本班";
|
|||
|
|
return [
|
|||
|
|
{ role: "system", content: "你是 iAOP 交接班报告生成助手。按以下章节模板生成摘要"
|
|||
|
|
+ "(对齐 handover_brief.ti.yaml):一、生产概况;二、异常事项;三、安全注意事项;"
|
|||
|
|
+ "四、能耗;五、待办(交下一班)。每章节 1-3 条,简体中文。" },
|
|||
|
|
{ role: "user", content: "请生成 " + shift + " 的交接班报告。本班关键信息:" + question }
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
// nl_query:带 RAG 上下文
|
|||
|
|
var ctx = citations.map(function (c, i) {
|
|||
|
|
return "[" + (i + 1) + "] 《" + c.title + "》" + c.heading + ":" + c.snippet;
|
|||
|
|
}).join("\n");
|
|||
|
|
return [
|
|||
|
|
{ role: "system", content: "你是 iAOP 工业驾驶舱 NL 查询助手,回答工艺/质量/能耗问题。"
|
|||
|
|
+ (ctx ? "可参考以下知识库片段(回答中可用 [编号] 标注引用):\n" + ctx
|
|||
|
|
: "知识库未命中,直接回答即可。")
|
|||
|
|
+ "用简体中文,简洁准确。" },
|
|||
|
|
{ role: "user", content: question }
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 发送主流程 ---------------- */
|
|||
|
|
function doSend(question, forceLocal) {
|
|||
|
|
var scenario = document.getElementById("scenario").value;
|
|||
|
|
var citations = scenario === "nl_query" ? retrieveCitations(question, 3) : [];
|
|||
|
|
var route = forceLocal
|
|||
|
|
? { target: "local", reason: "DLP 拦截后用户确认转本地处理" }
|
|||
|
|
: routeDecision(question + " " + (citations.map(function (c) {
|
|||
|
|
return c.snippet; }).join(" ")), dlpCheck(question));
|
|||
|
|
|
|||
|
|
addMsg("user", question);
|
|||
|
|
document.getElementById("question").value = "";
|
|||
|
|
var sendBtn = document.getElementById("send");
|
|||
|
|
sendBtn.disabled = true;
|
|||
|
|
var started = Date.now();
|
|||
|
|
|
|||
|
|
chatCompletions(buildMessages(scenario, question, citations))
|
|||
|
|
.then(function (answer) {
|
|||
|
|
var cost = ((Date.now() - started) / 1000).toFixed(1);
|
|||
|
|
var meta = [routeTag(route.target),
|
|||
|
|
el("span", null, "模型: " + (state.model || "default")),
|
|||
|
|
el("span", null, "耗时: " + cost + "s" +
|
|||
|
|
(scenario === "shift_handover" ? "(目标 ≤ 2min)" : ""))];
|
|||
|
|
// 高利害人工确认(PRD line 333):P0 报警解释提示值班长确认
|
|||
|
|
if (scenario === "alarm_explain" &&
|
|||
|
|
document.getElementById("alarm-severity").value === "P0") {
|
|||
|
|
meta.push(el("span", "human-tag", "⚠ P0 高利害:需值班长确认后执行"));
|
|||
|
|
}
|
|||
|
|
var div = addMsg("bot", answer, meta);
|
|||
|
|
if (scenario === "nl_query") appendCitations(div, citations);
|
|||
|
|
})
|
|||
|
|
.catch(function (e) {
|
|||
|
|
addMsg("bot", "请求失败:" + e.message + "(请确认推理服务可达)");
|
|||
|
|
})
|
|||
|
|
.finally(function () { sendBtn.disabled = false; });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function trySend() {
|
|||
|
|
var question = document.getElementById("question").value.trim();
|
|||
|
|
if (!question) return;
|
|||
|
|
var hits = dlpCheck(question);
|
|||
|
|
if (hits.length) {
|
|||
|
|
// DLP 拦截反馈(PRD 5.4:拦截可感知)——不发送,展示命中详情
|
|||
|
|
var banner = document.getElementById("dlp-banner");
|
|||
|
|
document.getElementById("dlp-detail").textContent =
|
|||
|
|
"命中规则 " + hits.map(function (h) {
|
|||
|
|
return h.rule.name + "(" + h.rule.description + ")";
|
|||
|
|
}).join("、") + ",已阻止外发云端(fail-closed)";
|
|||
|
|
banner.hidden = false;
|
|||
|
|
state.pending = question;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
doSend(question, false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 启动 ---------------- */
|
|||
|
|
(function init() {
|
|||
|
|
document.getElementById("send").onclick = trySend;
|
|||
|
|
document.getElementById("question").addEventListener("keydown", function (e) {
|
|||
|
|
if (e.key === "Enter") trySend();
|
|||
|
|
});
|
|||
|
|
document.getElementById("scenario").onchange = function (e) {
|
|||
|
|
var v = e.target.value;
|
|||
|
|
document.getElementById("alarm-inputs").hidden = v !== "alarm_explain";
|
|||
|
|
document.getElementById("handover-inputs").hidden = v !== "shift_handover";
|
|||
|
|
document.getElementById("question").placeholder = {
|
|||
|
|
nl_query: "请输入问题,如:氯气流量最近1小时趋势",
|
|||
|
|
alarm_explain: "描述告警现象,如:炉温骤升至 920℃ 且持续 5 分钟",
|
|||
|
|
shift_handover: "输入本班关键事件/数据,如:氯化炉运行平稳,P1 告警 2 起已确认"
|
|||
|
|
}[v];
|
|||
|
|
};
|
|||
|
|
document.getElementById("btn-local-fallback").onclick = function () {
|
|||
|
|
document.getElementById("dlp-banner").hidden = true;
|
|||
|
|
if (state.pending) { doSend(state.pending, true); state.pending = null; }
|
|||
|
|
};
|
|||
|
|
document.getElementById("btn-dlp-dismiss").onclick = function () {
|
|||
|
|
document.getElementById("dlp-banner").hidden = true;
|
|||
|
|
state.pending = null;
|
|||
|
|
};
|
|||
|
|
fetch("citations.json").then(function (r) { return r.ok ? r.json() : null; })
|
|||
|
|
.then(function (d) { state.citations = d; }).catch(function () {});
|
|||
|
|
checkHealth();
|
|||
|
|
loadModel();
|
|||
|
|
})();
|