Files
iAOP/web/chat/assistant.js
T

284 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/* 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();
})();